Ktor 的架構設計是讓開發者透過實作 plugin,把 intercepting function 註冊到 request pipeline,藉此增加功能或改變行為
我們要先使用 install function 來安裝 plugin,然後在 trailing lambda 進行設定。例如 Ktor 的 CallLogging plugin,可以在 request 一進來的時候,就寫入 log 記錄資料. 我們可以實作 filter function 過濾 request,還有 format function 調整格式。
install(CallLogging) {
filter { call ->
call.request.path().startsWith("/api/v1")
}
format { call ->
val status = call.response.status()
val httpMethod = call.request.httpMethod.value
val userAgent = call.request.headers["User-Agent"]
"Status: $status, HTTP method: $httpMethod, User agent: $userAgent"
}
}
Ktor 的設計原則是使用 DSL & Lambda 的寫法撰寫具有 declarative 風格的程式碼,開發者可以從 main function 開始,依照自己的想法開發所有功能,所以開發者可以選擇不使用 DI 開發,Ktor 自然也就沒有內建 DI,這與 Spring Framework 要求實作特定介面或繼承特定類別,再透過 DI 注入的方式有很大的不同。另一方面,Ktor 也很少定義介面要求我們實作,我目前也只有用到 Credential, Principal interface 而已,而且還是 marker interface。
目前官方對 Ktor 的定位是保持核心精簡,所以官方現有的 Plugin 都是 HTTP Server 的相關功能。對於這種 Plugin 來說,開發者在安裝設定之後,就幾乎不必再對 Plugin 做什麼操作。但如果我們把 Plugin 的解釋範圍擴大為任何一個功能,或是整合其它函式庫或框架,此時就需要在我們安裝 Plugin 之後,從 Plugin 取得某個己初始化的物件進行操作。例如我們在安裝 Redis Plugin 之後,需要取得 RedisClient 物件進行操作,此時我們必須思考要透過什麼方式拿到 RedisClient 物件,目前我認為使用 DI 取得物件是最低耦合的方式。
我曾使用過的 Spring 本身就是個 DI 為基礎的框架,至於 Play Framework 則是使用 Google Guice,Guice 比較輕量,但兩者都是 annotation based。對於 Ktor 來說,我想挑選「純 kotlin」、「輕量」、「DSL風格」的 DI,最後我選擇了 Koin,而且 Koin 已經支援 Ktor,有 Koin Plugin 可以直接使用。
啟動 Ktor 一進入 main function 時,就要先安裝 Koin Plugin,這樣後續的 Plugin 才能使用 DI。接下來是設定 Koin Logging,才能知道 Koin 目前狀態方便 debug。雖然 Koin 本身已內建 SLF4JLogger 實作,但因為我是使用 KotlinLogging library,所以必須要自己實作 Logger。最後再把設定檔 appConfig 物件注入至 Koin,畢竟設定檔物件在很多地方都會用到
fun Application.main() {
val appConfig = ApplicationConfigLoader.load()
install(Koin) {
logger(KoinLogger(Level.INFO))
modules(
module(createdAtStart = true) {
single { appConfig }
},
koinBaseModule(appConfig)
)
}
}
class KoinLogger(level: Level = Level.INFO) : Logger(level) {
private val logger = KotlinLogging.logger("[Koin]")
override fun log(level: Level, msg: MESSAGE) {
when (level) {
Level.DEBUG -> logger.debug { msg }
Level.INFO -> logger.info { msg }
Level.ERROR -> logger.error { msg }
Level.NONE -> logger.trace { msg }
}
}
}
初始化 Koin Plugin 之後,我們以 Redis Plugin 為例,來看如何使用 DI 初始化注入 Redis Client 物件。剛才有提到我們是呼叫 install function 來安裝 plugin,所以在 install function 裡面,透過 koin 內建的 Application.koin() extension function 來載入 koin module,module 裡面使用 single function 將 RedisClient 物件載入至 Koin 裡面。
override fun install(pipeline: Application, configure: Configuration.() -> Unit): RedisFeature {
val configuration = Configuration().apply(configure)
val feature = RedisFeature(configuration)
val appConfig = pipeline.get<MyApplicationConfig>()
config = appConfig.infra.redis ?: configuration.build()
initClient(config)
pipeline.koin {
modules(
module(createdAtStart = true) {
single { client }
KoinApplicationShutdownManager.register { shutdown() }
}
)
}
return feature
}
後續可以在其它 Plugin 的 install function 透過 pipeline.get<RedisClient>()
取得物件,如果是在 Ktor Routing function 可以使用 val redisClient by inject<RedisClient>()
取得。
在初始化 Plugin 的過程中,除了與 Koin DI 整合,讀取 Ktor 外部設定檔也是不可或缺的,畢竟許多 Plugin 的設定值是從外部來的,明天會再進一步說明如何讀取設定檔轉換為自己定義的 appConfig 物件。